Skip to main content

shodh_memory/memory/
sessions.rs

1//! Session Tracking Module
2//!
3//! Tracks user sessions with timeline, metrics, and analytics.
4//! Each session represents a conversation/work period with the AI.
5
6use chrono::{DateTime, Datelike, Duration, Local, Timelike, Utc};
7use parking_lot::RwLock;
8use serde::{Deserialize, Serialize};
9use std::collections::HashMap;
10use uuid::Uuid;
11
12/// Time of day classification
13#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
14#[serde(rename_all = "snake_case")]
15pub enum TimeOfDay {
16    EarlyMorning, // 5-8
17    Morning,      // 8-12
18    Afternoon,    // 12-17
19    Evening,      // 17-21
20    Night,        // 21-5
21}
22
23impl TimeOfDay {
24    /// Classify hour into time of day
25    pub fn from_hour(hour: u32) -> Self {
26        match hour {
27            5..=7 => Self::EarlyMorning,
28            8..=11 => Self::Morning,
29            12..=16 => Self::Afternoon,
30            17..=20 => Self::Evening,
31            _ => Self::Night,
32        }
33    }
34
35    /// Human-readable label
36    pub fn label(&self) -> &'static str {
37        match self {
38            Self::EarlyMorning => "Early morning",
39            Self::Morning => "Morning",
40            Self::Afternoon => "Afternoon",
41            Self::Evening => "Evening",
42            Self::Night => "Night",
43        }
44    }
45
46    /// Short label for compact display
47    pub fn short_label(&self) -> &'static str {
48        match self {
49            Self::EarlyMorning => "early AM",
50            Self::Morning => "AM",
51            Self::Afternoon => "PM",
52            Self::Evening => "evening",
53            Self::Night => "night",
54        }
55    }
56}
57
58/// Temporal context for a session
59#[derive(Debug, Clone, Serialize, Deserialize)]
60pub struct TemporalContext {
61    /// Time of day when session started
62    pub time_of_day: TimeOfDay,
63    /// Day of week (Monday=0, Sunday=6)
64    pub day_of_week: u32,
65    /// Day of week name
66    pub day_name: String,
67    /// Month name
68    pub month_name: String,
69    /// Day of month
70    pub day: u32,
71    /// Year
72    pub year: i32,
73    /// Human-readable label like "Morning session of Dec 20th"
74    pub label: String,
75    /// Relative time label like "Today", "Yesterday", "Last week"
76    pub relative: String,
77}
78
79impl TemporalContext {
80    /// Create temporal context from a datetime
81    pub fn from_datetime(dt: DateTime<Utc>) -> Self {
82        let local = dt.with_timezone(&Local);
83        let now = Local::now();
84
85        let time_of_day = TimeOfDay::from_hour(local.hour());
86        let day_of_week = local.weekday().num_days_from_monday();
87
88        let day_name = match local.weekday() {
89            chrono::Weekday::Mon => "Monday",
90            chrono::Weekday::Tue => "Tuesday",
91            chrono::Weekday::Wed => "Wednesday",
92            chrono::Weekday::Thu => "Thursday",
93            chrono::Weekday::Fri => "Friday",
94            chrono::Weekday::Sat => "Saturday",
95            chrono::Weekday::Sun => "Sunday",
96        }
97        .to_string();
98
99        let month_name = match local.month() {
100            1 => "Jan",
101            2 => "Feb",
102            3 => "Mar",
103            4 => "Apr",
104            5 => "May",
105            6 => "Jun",
106            7 => "Jul",
107            8 => "Aug",
108            9 => "Sep",
109            10 => "Oct",
110            11 => "Nov",
111            12 => "Dec",
112            _ => "???",
113        }
114        .to_string();
115
116        let day = local.day();
117        let year = local.year();
118
119        // Ordinal suffix
120        let ordinal = match day {
121            1 | 21 | 31 => "st",
122            2 | 22 => "nd",
123            3 | 23 => "rd",
124            _ => "th",
125        };
126
127        // Relative time
128        let days_ago = (now.date_naive() - local.date_naive()).num_days();
129        let relative = match days_ago {
130            0 => "Today".to_string(),
131            1 => "Yesterday".to_string(),
132            2..=6 => format!("This {}", day_name),
133            7..=13 => "Last week".to_string(),
134            14..=30 => format!("{} weeks ago", days_ago / 7),
135            _ => format!("{} {} {}", month_name, day, year),
136        };
137
138        // Full label: "Morning session of Dec 20th" or "Today's morning session"
139        let label = if days_ago == 0 {
140            format!("Today's {} session", time_of_day.label().to_lowercase())
141        } else if days_ago == 1 {
142            format!("Yesterday's {} session", time_of_day.label().to_lowercase())
143        } else {
144            format!(
145                "{} session of {} {}{}",
146                time_of_day.label(),
147                month_name,
148                day,
149                ordinal
150            )
151        };
152
153        Self {
154            time_of_day,
155            day_of_week,
156            day_name,
157            month_name,
158            day,
159            year,
160            label,
161            relative,
162        }
163    }
164
165    /// Short label like "Dec 20 AM"
166    pub fn short_label(&self) -> String {
167        format!(
168            "{} {} {}",
169            self.month_name,
170            self.day,
171            self.time_of_day.short_label()
172        )
173    }
174}
175
176/// Unique session identifier
177#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
178pub struct SessionId(pub Uuid);
179
180impl SessionId {
181    pub fn new() -> Self {
182        Self(Uuid::new_v4())
183    }
184
185    pub fn from_uuid(uuid: Uuid) -> Self {
186        Self(uuid)
187    }
188
189    pub fn short(&self) -> String {
190        self.0.to_string()[..8].to_string()
191    }
192}
193
194impl Default for SessionId {
195    fn default() -> Self {
196        Self::new()
197    }
198}
199
200impl std::fmt::Display for SessionId {
201    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
202        write!(f, "{}", self.0)
203    }
204}
205
206/// Session status
207#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
208#[serde(rename_all = "snake_case")]
209pub enum SessionStatus {
210    /// Currently active session
211    Active,
212    /// Session completed normally
213    Completed,
214    /// Session timed out or abandoned
215    Abandoned,
216}
217
218/// Event types that can occur during a session
219#[derive(Debug, Clone, Serialize, Deserialize)]
220#[serde(tag = "type", rename_all = "snake_case")]
221pub enum SessionEvent {
222    /// Session started
223    SessionStart { timestamp: DateTime<Utc> },
224
225    /// Memory was created
226    MemoryCreated {
227        timestamp: DateTime<Utc>,
228        memory_id: String,
229        memory_type: String,
230        content_preview: String,
231        entities: Vec<String>,
232    },
233
234    /// Memories were surfaced for a query
235    MemoriesSurfaced {
236        timestamp: DateTime<Utc>,
237        query_preview: String,
238        memory_count: usize,
239        memory_ids: Vec<String>,
240        avg_score: f32,
241    },
242
243    /// Memory was used in response (entity flow detected usage)
244    MemoryUsed {
245        timestamp: DateTime<Utc>,
246        memory_id: String,
247        derived_ratio: f32,
248    },
249
250    /// Todo was created
251    TodoCreated {
252        timestamp: DateTime<Utc>,
253        todo_id: String,
254        content: String,
255        project: Option<String>,
256    },
257
258    /// Todo was completed
259    TodoCompleted {
260        timestamp: DateTime<Utc>,
261        todo_id: String,
262    },
263
264    /// Topic changed (detected via low similarity)
265    TopicChange {
266        timestamp: DateTime<Utc>,
267        similarity: f32,
268    },
269
270    /// Query was processed
271    QueryProcessed {
272        timestamp: DateTime<Utc>,
273        query_preview: String,
274        tokens_estimated: usize,
275    },
276
277    /// Session ended
278    SessionEnd {
279        timestamp: DateTime<Utc>,
280        reason: String,
281    },
282}
283
284impl SessionEvent {
285    pub fn timestamp(&self) -> DateTime<Utc> {
286        match self {
287            Self::SessionStart { timestamp } => *timestamp,
288            Self::MemoryCreated { timestamp, .. } => *timestamp,
289            Self::MemoriesSurfaced { timestamp, .. } => *timestamp,
290            Self::MemoryUsed { timestamp, .. } => *timestamp,
291            Self::TodoCreated { timestamp, .. } => *timestamp,
292            Self::TodoCompleted { timestamp, .. } => *timestamp,
293            Self::TopicChange { timestamp, .. } => *timestamp,
294            Self::QueryProcessed { timestamp, .. } => *timestamp,
295            Self::SessionEnd { timestamp, .. } => *timestamp,
296        }
297    }
298
299    pub fn event_type(&self) -> &'static str {
300        match self {
301            Self::SessionStart { .. } => "session_start",
302            Self::MemoryCreated { .. } => "memory_created",
303            Self::MemoriesSurfaced { .. } => "memories_surfaced",
304            Self::MemoryUsed { .. } => "memory_used",
305            Self::TodoCreated { .. } => "todo_created",
306            Self::TodoCompleted { .. } => "todo_completed",
307            Self::TopicChange { .. } => "topic_change",
308            Self::QueryProcessed { .. } => "query_processed",
309            Self::SessionEnd { .. } => "session_end",
310        }
311    }
312}
313
314/// Session statistics
315#[derive(Debug, Clone, Default, Serialize, Deserialize)]
316pub struct SessionStats {
317    /// Number of memories created
318    pub memories_created: usize,
319    /// Number of memories surfaced
320    pub memories_surfaced: usize,
321    /// Number of memories actually used (derived_ratio > 0)
322    pub memories_used: usize,
323    /// Memory hit rate (used / surfaced)
324    pub memory_hit_rate: f32,
325    /// Number of todos created
326    pub todos_created: usize,
327    /// Number of todos completed
328    pub todos_completed: usize,
329    /// Todo completion rate
330    pub todo_completion_rate: f32,
331    /// Number of queries processed
332    pub queries_count: usize,
333    /// Estimated tokens used
334    pub tokens_estimated: usize,
335    /// Number of topic changes
336    pub topic_changes: usize,
337}
338
339impl SessionStats {
340    pub fn compute_rates(&mut self) {
341        self.memory_hit_rate = if self.memories_surfaced > 0 {
342            self.memories_used as f32 / self.memories_surfaced as f32
343        } else {
344            0.0
345        };
346
347        self.todo_completion_rate = if self.todos_created > 0 {
348            self.todos_completed as f32 / self.todos_created as f32
349        } else {
350            0.0
351        };
352    }
353}
354
355/// A user session
356#[derive(Debug, Clone, Serialize, Deserialize)]
357pub struct Session {
358    /// Session ID
359    pub id: SessionId,
360    /// User ID
361    pub user_id: String,
362    /// Session status
363    pub status: SessionStatus,
364    /// When session started
365    pub started_at: DateTime<Utc>,
366    /// When session ended (if completed/abandoned)
367    pub ended_at: Option<DateTime<Utc>>,
368    /// Session duration in seconds
369    pub duration_secs: Option<i64>,
370    /// Temporal context (time of day, relative date, etc.)
371    pub temporal: TemporalContext,
372    /// Session statistics
373    pub stats: SessionStats,
374    /// Timeline of events
375    pub timeline: Vec<SessionEvent>,
376    /// Optional session name/label (user-provided)
377    pub label: Option<String>,
378    /// Metadata
379    pub metadata: HashMap<String, serde_json::Value>,
380}
381
382impl Session {
383    pub fn new(user_id: String) -> Self {
384        let now = Utc::now();
385        Self {
386            id: SessionId::new(),
387            user_id,
388            status: SessionStatus::Active,
389            started_at: now,
390            ended_at: None,
391            duration_secs: None,
392            temporal: TemporalContext::from_datetime(now),
393            stats: SessionStats::default(),
394            timeline: vec![SessionEvent::SessionStart { timestamp: now }],
395            label: None,
396            metadata: HashMap::new(),
397        }
398    }
399
400    pub fn with_id(user_id: String, session_id: SessionId) -> Self {
401        let now = Utc::now();
402        Self {
403            id: session_id,
404            user_id,
405            status: SessionStatus::Active,
406            started_at: now,
407            ended_at: None,
408            duration_secs: None,
409            temporal: TemporalContext::from_datetime(now),
410            stats: SessionStats::default(),
411            timeline: vec![SessionEvent::SessionStart { timestamp: now }],
412            label: None,
413            metadata: HashMap::new(),
414        }
415    }
416
417    /// Get human-readable temporal label like "Morning session of Dec 20th"
418    pub fn temporal_label(&self) -> &str {
419        &self.temporal.label
420    }
421
422    /// Get short temporal label like "Dec 20 AM"
423    pub fn short_temporal_label(&self) -> String {
424        self.temporal.short_label()
425    }
426
427    /// Add an event to the timeline
428    pub fn add_event(&mut self, event: SessionEvent) {
429        // Update stats based on event type
430        match &event {
431            SessionEvent::MemoryCreated { .. } => {
432                self.stats.memories_created += 1;
433            }
434            SessionEvent::MemoriesSurfaced { memory_count, .. } => {
435                self.stats.memories_surfaced += memory_count;
436            }
437            SessionEvent::MemoryUsed { .. } => {
438                self.stats.memories_used += 1;
439            }
440            SessionEvent::TodoCreated { .. } => {
441                self.stats.todos_created += 1;
442            }
443            SessionEvent::TodoCompleted { .. } => {
444                self.stats.todos_completed += 1;
445            }
446            SessionEvent::TopicChange { .. } => {
447                self.stats.topic_changes += 1;
448            }
449            SessionEvent::QueryProcessed {
450                tokens_estimated, ..
451            } => {
452                self.stats.queries_count += 1;
453                self.stats.tokens_estimated += tokens_estimated;
454            }
455            _ => {}
456        }
457
458        self.stats.compute_rates();
459        self.timeline.push(event);
460    }
461
462    /// End the session
463    pub fn end(&mut self, reason: &str) {
464        let now = Utc::now();
465        self.status = if reason == "timeout" || reason == "abandoned" {
466            SessionStatus::Abandoned
467        } else {
468            SessionStatus::Completed
469        };
470        self.ended_at = Some(now);
471        self.duration_secs = Some((now - self.started_at).num_seconds());
472        self.timeline.push(SessionEvent::SessionEnd {
473            timestamp: now,
474            reason: reason.to_string(),
475        });
476        self.stats.compute_rates();
477    }
478
479    /// Check if session is active
480    pub fn is_active(&self) -> bool {
481        self.status == SessionStatus::Active
482    }
483
484    /// Get session duration
485    pub fn duration(&self) -> Duration {
486        let end = self.ended_at.unwrap_or_else(Utc::now);
487        end - self.started_at
488    }
489
490    /// Get summary for display
491    pub fn summary(&self) -> SessionSummary {
492        SessionSummary {
493            id: self.id.clone(),
494            user_id: self.user_id.clone(),
495            status: self.status.clone(),
496            started_at: self.started_at,
497            ended_at: self.ended_at,
498            duration_secs: self.duration().num_seconds(),
499            temporal: self.temporal.clone(),
500            label: self.label.clone(),
501            stats: self.stats.clone(),
502        }
503    }
504}
505
506/// Lightweight session summary (without full timeline)
507#[derive(Debug, Clone, Serialize, Deserialize)]
508pub struct SessionSummary {
509    pub id: SessionId,
510    pub user_id: String,
511    pub status: SessionStatus,
512    pub started_at: DateTime<Utc>,
513    pub ended_at: Option<DateTime<Utc>>,
514    pub duration_secs: i64,
515    /// Temporal context with human-readable labels
516    pub temporal: TemporalContext,
517    /// User-provided label
518    pub label: Option<String>,
519    pub stats: SessionStats,
520}
521
522impl SessionSummary {
523    /// Get the display title for this session
524    /// Uses user label if set, otherwise temporal label
525    pub fn display_title(&self) -> &str {
526        self.label.as_deref().unwrap_or(&self.temporal.label)
527    }
528}
529
530/// Session store - manages all sessions for users
531pub struct SessionStore {
532    /// Active sessions by session ID
533    active: RwLock<HashMap<SessionId, Session>>,
534    /// Completed sessions (ring buffer per user, keeps last N)
535    completed: RwLock<HashMap<String, Vec<Session>>>,
536    /// Maximum completed sessions to keep per user
537    max_completed_per_user: usize,
538    /// Session timeout in seconds
539    timeout_secs: i64,
540}
541
542impl SessionStore {
543    pub fn new() -> Self {
544        Self {
545            active: RwLock::new(HashMap::new()),
546            completed: RwLock::new(HashMap::new()),
547            max_completed_per_user: 50,
548            timeout_secs: 3600, // 1 hour
549        }
550    }
551
552    pub fn with_config(max_completed_per_user: usize, timeout_secs: i64) -> Self {
553        Self {
554            active: RwLock::new(HashMap::new()),
555            completed: RwLock::new(HashMap::new()),
556            max_completed_per_user,
557            timeout_secs,
558        }
559    }
560
561    /// Start a new session for a user
562    pub fn start_session(&self, user_id: &str) -> SessionId {
563        let session = Session::new(user_id.to_string());
564        let id = session.id.clone();
565        self.active.write().insert(id.clone(), session);
566        id
567    }
568
569    /// Start a session with a specific ID (for resumption)
570    pub fn start_session_with_id(&self, user_id: &str, session_id: SessionId) -> SessionId {
571        let session = Session::with_id(user_id.to_string(), session_id.clone());
572        self.active.write().insert(session_id.clone(), session);
573        session_id
574    }
575
576    /// Get or create active session for user
577    pub fn get_or_create_session(&self, user_id: &str) -> SessionId {
578        // Check if user has an active session
579        {
580            let active = self.active.read();
581            for (id, session) in active.iter() {
582                if session.user_id == user_id && session.is_active() {
583                    return id.clone();
584                }
585            }
586        }
587        // No active session, create one
588        self.start_session(user_id)
589    }
590
591    /// Add event to a session
592    pub fn add_event(&self, session_id: &SessionId, event: SessionEvent) -> bool {
593        let mut active = self.active.write();
594        if let Some(session) = active.get_mut(session_id) {
595            session.add_event(event);
596            true
597        } else {
598            false
599        }
600    }
601
602    /// Add event to user's active session
603    pub fn add_event_to_user(&self, user_id: &str, event: SessionEvent) -> Option<SessionId> {
604        let mut active = self.active.write();
605        for (id, session) in active.iter_mut() {
606            if session.user_id == user_id && session.is_active() {
607                session.add_event(event);
608                return Some(id.clone());
609            }
610        }
611        None
612    }
613
614    /// End a session
615    pub fn end_session(&self, session_id: &SessionId, reason: &str) -> Option<Session> {
616        let mut active = self.active.write();
617        if let Some(mut session) = active.remove(session_id) {
618            session.end(reason);
619
620            // Move to completed
621            let mut completed = self.completed.write();
622            let user_sessions = completed
623                .entry(session.user_id.clone())
624                .or_insert_with(Vec::new);
625            user_sessions.push(session.clone());
626
627            // Trim to max
628            if user_sessions.len() > self.max_completed_per_user {
629                let excess = user_sessions.len() - self.max_completed_per_user;
630                user_sessions.drain(0..excess);
631            }
632
633            Some(session)
634        } else {
635            None
636        }
637    }
638
639    /// Get session by ID
640    pub fn get_session(&self, session_id: &SessionId) -> Option<Session> {
641        // Check active first
642        if let Some(session) = self.active.read().get(session_id) {
643            return Some(session.clone());
644        }
645        // Check completed
646        let completed = self.completed.read();
647        for sessions in completed.values() {
648            if let Some(session) = sessions.iter().find(|s| &s.id == session_id) {
649                return Some(session.clone());
650            }
651        }
652        None
653    }
654
655    /// Get all sessions for a user
656    pub fn get_user_sessions(&self, user_id: &str, limit: usize) -> Vec<SessionSummary> {
657        let mut result = Vec::new();
658
659        // Add active sessions
660        {
661            let active = self.active.read();
662            for session in active.values() {
663                if session.user_id == user_id {
664                    result.push(session.summary());
665                }
666            }
667        }
668
669        // Add completed sessions
670        {
671            let completed = self.completed.read();
672            if let Some(sessions) = completed.get(user_id) {
673                for session in sessions
674                    .iter()
675                    .rev()
676                    .take(limit.saturating_sub(result.len()))
677                {
678                    result.push(session.summary());
679                }
680            }
681        }
682
683        // Sort by start time (newest first)
684        result.sort_by(|a, b| b.started_at.cmp(&a.started_at));
685        result.truncate(limit);
686        result
687    }
688
689    /// Get active session for user
690    pub fn get_active_session(&self, user_id: &str) -> Option<Session> {
691        let active = self.active.read();
692        for session in active.values() {
693            if session.user_id == user_id && session.is_active() {
694                return Some(session.clone());
695            }
696        }
697        None
698    }
699
700    /// Cleanup stale sessions
701    pub fn cleanup_stale_sessions(&self) -> usize {
702        let now = Utc::now();
703        let timeout = Duration::seconds(self.timeout_secs);
704
705        let stale_ids: Vec<SessionId> = {
706            let active = self.active.read();
707            active
708                .iter()
709                .filter(|(_, s)| now - s.started_at > timeout)
710                .map(|(id, _)| id.clone())
711                .collect()
712        };
713
714        let count = stale_ids.len();
715        for id in stale_ids {
716            self.end_session(&id, "timeout");
717        }
718        count
719    }
720
721    /// Get store statistics
722    pub fn stats(&self) -> SessionStoreStats {
723        let active = self.active.read();
724        let completed = self.completed.read();
725
726        let total_completed: usize = completed.values().map(|v| v.len()).sum();
727
728        SessionStoreStats {
729            active_sessions: active.len(),
730            completed_sessions: total_completed,
731            users_with_sessions: completed.len(),
732        }
733    }
734}
735
736impl Default for SessionStore {
737    fn default() -> Self {
738        Self::new()
739    }
740}
741
742/// Session store statistics
743#[derive(Debug, Clone, Serialize, Deserialize)]
744pub struct SessionStoreStats {
745    pub active_sessions: usize,
746    pub completed_sessions: usize,
747    pub users_with_sessions: usize,
748}
749
750#[cfg(test)]
751mod tests {
752    use super::*;
753
754    #[test]
755    fn test_session_lifecycle() {
756        let store = SessionStore::new();
757
758        // Start session
759        let session_id = store.start_session("test-user");
760        assert!(store.get_session(&session_id).is_some());
761
762        // Add events
763        store.add_event(
764            &session_id,
765            SessionEvent::MemoryCreated {
766                timestamp: Utc::now(),
767                memory_id: "mem-1".to_string(),
768                memory_type: "Learning".to_string(),
769                content_preview: "Test memory".to_string(),
770                entities: vec!["rust".to_string()],
771            },
772        );
773
774        store.add_event(
775            &session_id,
776            SessionEvent::TodoCreated {
777                timestamp: Utc::now(),
778                todo_id: "todo-1".to_string(),
779                content: "Test todo".to_string(),
780                project: None,
781            },
782        );
783
784        // Check stats
785        let session = store.get_session(&session_id).unwrap();
786        assert_eq!(session.stats.memories_created, 1);
787        assert_eq!(session.stats.todos_created, 1);
788        assert_eq!(session.timeline.len(), 3); // start + 2 events
789
790        // End session
791        let ended = store.end_session(&session_id, "completed").unwrap();
792        assert_eq!(ended.status, SessionStatus::Completed);
793        assert!(ended.ended_at.is_some());
794
795        // Should be in completed now
796        let sessions = store.get_user_sessions("test-user", 10);
797        assert_eq!(sessions.len(), 1);
798        assert_eq!(sessions[0].status, SessionStatus::Completed);
799    }
800
801    #[test]
802    fn test_memory_hit_rate() {
803        let store = SessionStore::new();
804        let session_id = store.start_session("test-user");
805
806        // Surface 10 memories
807        store.add_event(
808            &session_id,
809            SessionEvent::MemoriesSurfaced {
810                timestamp: Utc::now(),
811                query_preview: "test query".to_string(),
812                memory_count: 10,
813                memory_ids: (0..10).map(|i| format!("mem-{}", i)).collect(),
814                avg_score: 0.8,
815            },
816        );
817
818        // Use 3 of them
819        for i in 0..3 {
820            store.add_event(
821                &session_id,
822                SessionEvent::MemoryUsed {
823                    timestamp: Utc::now(),
824                    memory_id: format!("mem-{}", i),
825                    derived_ratio: 0.5,
826                },
827            );
828        }
829
830        let session = store.get_session(&session_id).unwrap();
831        assert_eq!(session.stats.memories_surfaced, 10);
832        assert_eq!(session.stats.memories_used, 3);
833        assert!((session.stats.memory_hit_rate - 0.3).abs() < 0.01);
834    }
835
836    #[test]
837    fn test_get_or_create() {
838        let store = SessionStore::new();
839
840        // First call creates session
841        let id1 = store.get_or_create_session("user-1");
842
843        // Second call returns same session
844        let id2 = store.get_or_create_session("user-1");
845        assert_eq!(id1, id2);
846
847        // Different user gets different session
848        let id3 = store.get_or_create_session("user-2");
849        assert_ne!(id1, id3);
850    }
851
852    #[test]
853    fn test_temporal_context() {
854        // Test time of day classification
855        assert_eq!(TimeOfDay::from_hour(6), TimeOfDay::EarlyMorning);
856        assert_eq!(TimeOfDay::from_hour(10), TimeOfDay::Morning);
857        assert_eq!(TimeOfDay::from_hour(14), TimeOfDay::Afternoon);
858        assert_eq!(TimeOfDay::from_hour(19), TimeOfDay::Evening);
859        assert_eq!(TimeOfDay::from_hour(23), TimeOfDay::Night);
860        assert_eq!(TimeOfDay::from_hour(3), TimeOfDay::Night);
861
862        // Test labels
863        assert_eq!(TimeOfDay::Morning.label(), "Morning");
864        assert_eq!(TimeOfDay::Afternoon.short_label(), "PM");
865
866        // Test temporal context creation
867        let now = Utc::now();
868        let ctx = TemporalContext::from_datetime(now);
869
870        // Should have valid data
871        assert!(!ctx.day_name.is_empty());
872        assert!(!ctx.month_name.is_empty());
873        assert!(ctx.day >= 1 && ctx.day <= 31);
874        assert!(!ctx.label.is_empty());
875        assert!(!ctx.relative.is_empty());
876
877        // Today's session should say "Today's"
878        assert!(ctx.label.contains("Today's") || ctx.relative == "Today");
879    }
880
881    #[test]
882    fn test_session_temporal_label() {
883        let store = SessionStore::new();
884        let session_id = store.start_session("test-user");
885
886        let session = store.get_session(&session_id).unwrap();
887
888        // Should have temporal context
889        assert!(!session.temporal.label.is_empty());
890        assert!(session.temporal_label().contains("Today's"));
891
892        // Summary should include temporal
893        let summary = session.summary();
894        assert!(!summary.temporal.label.is_empty());
895        assert_eq!(summary.display_title(), session.temporal_label());
896    }
897}