1use chrono::{DateTime, Datelike, Duration, Local, Timelike, Utc};
7use parking_lot::RwLock;
8use serde::{Deserialize, Serialize};
9use std::collections::HashMap;
10use uuid::Uuid;
11
12#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
14#[serde(rename_all = "snake_case")]
15pub enum TimeOfDay {
16 EarlyMorning, Morning, Afternoon, Evening, Night, }
22
23impl TimeOfDay {
24 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 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 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#[derive(Debug, Clone, Serialize, Deserialize)]
60pub struct TemporalContext {
61 pub time_of_day: TimeOfDay,
63 pub day_of_week: u32,
65 pub day_name: String,
67 pub month_name: String,
69 pub day: u32,
71 pub year: i32,
73 pub label: String,
75 pub relative: String,
77}
78
79impl TemporalContext {
80 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 let ordinal = match day {
121 1 | 21 | 31 => "st",
122 2 | 22 => "nd",
123 3 | 23 => "rd",
124 _ => "th",
125 };
126
127 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 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 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#[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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
208#[serde(rename_all = "snake_case")]
209pub enum SessionStatus {
210 Active,
212 Completed,
214 Abandoned,
216}
217
218#[derive(Debug, Clone, Serialize, Deserialize)]
220#[serde(tag = "type", rename_all = "snake_case")]
221pub enum SessionEvent {
222 SessionStart { timestamp: DateTime<Utc> },
224
225 MemoryCreated {
227 timestamp: DateTime<Utc>,
228 memory_id: String,
229 memory_type: String,
230 content_preview: String,
231 entities: Vec<String>,
232 },
233
234 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 MemoryUsed {
245 timestamp: DateTime<Utc>,
246 memory_id: String,
247 derived_ratio: f32,
248 },
249
250 TodoCreated {
252 timestamp: DateTime<Utc>,
253 todo_id: String,
254 content: String,
255 project: Option<String>,
256 },
257
258 TodoCompleted {
260 timestamp: DateTime<Utc>,
261 todo_id: String,
262 },
263
264 TopicChange {
266 timestamp: DateTime<Utc>,
267 similarity: f32,
268 },
269
270 QueryProcessed {
272 timestamp: DateTime<Utc>,
273 query_preview: String,
274 tokens_estimated: usize,
275 },
276
277 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#[derive(Debug, Clone, Default, Serialize, Deserialize)]
316pub struct SessionStats {
317 pub memories_created: usize,
319 pub memories_surfaced: usize,
321 pub memories_used: usize,
323 pub memory_hit_rate: f32,
325 pub todos_created: usize,
327 pub todos_completed: usize,
329 pub todo_completion_rate: f32,
331 pub queries_count: usize,
333 pub tokens_estimated: usize,
335 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#[derive(Debug, Clone, Serialize, Deserialize)]
357pub struct Session {
358 pub id: SessionId,
360 pub user_id: String,
362 pub status: SessionStatus,
364 pub started_at: DateTime<Utc>,
366 pub ended_at: Option<DateTime<Utc>>,
368 pub duration_secs: Option<i64>,
370 pub temporal: TemporalContext,
372 pub stats: SessionStats,
374 pub timeline: Vec<SessionEvent>,
376 pub label: Option<String>,
378 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 pub fn temporal_label(&self) -> &str {
419 &self.temporal.label
420 }
421
422 pub fn short_temporal_label(&self) -> String {
424 self.temporal.short_label()
425 }
426
427 pub fn add_event(&mut self, event: SessionEvent) {
429 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 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 pub fn is_active(&self) -> bool {
481 self.status == SessionStatus::Active
482 }
483
484 pub fn duration(&self) -> Duration {
486 let end = self.ended_at.unwrap_or_else(Utc::now);
487 end - self.started_at
488 }
489
490 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#[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 pub temporal: TemporalContext,
517 pub label: Option<String>,
519 pub stats: SessionStats,
520}
521
522impl SessionSummary {
523 pub fn display_title(&self) -> &str {
526 self.label.as_deref().unwrap_or(&self.temporal.label)
527 }
528}
529
530pub struct SessionStore {
532 active: RwLock<HashMap<SessionId, Session>>,
534 completed: RwLock<HashMap<String, Vec<Session>>>,
536 max_completed_per_user: usize,
538 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, }
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 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 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 pub fn get_or_create_session(&self, user_id: &str) -> SessionId {
578 {
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 self.start_session(user_id)
589 }
590
591 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 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 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 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 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 pub fn get_session(&self, session_id: &SessionId) -> Option<Session> {
641 if let Some(session) = self.active.read().get(session_id) {
643 return Some(session.clone());
644 }
645 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 pub fn get_user_sessions(&self, user_id: &str, limit: usize) -> Vec<SessionSummary> {
657 let mut result = Vec::new();
658
659 {
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 {
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 result.sort_by(|a, b| b.started_at.cmp(&a.started_at));
685 result.truncate(limit);
686 result
687 }
688
689 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 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 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#[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 let session_id = store.start_session("test-user");
760 assert!(store.get_session(&session_id).is_some());
761
762 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 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); 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 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 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 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 let id1 = store.get_or_create_session("user-1");
842
843 let id2 = store.get_or_create_session("user-1");
845 assert_eq!(id1, id2);
846
847 let id3 = store.get_or_create_session("user-2");
849 assert_ne!(id1, id3);
850 }
851
852 #[test]
853 fn test_temporal_context() {
854 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 assert_eq!(TimeOfDay::Morning.label(), "Morning");
864 assert_eq!(TimeOfDay::Afternoon.short_label(), "PM");
865
866 let now = Utc::now();
868 let ctx = TemporalContext::from_datetime(now);
869
870 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 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 assert!(!session.temporal.label.is_empty());
890 assert!(session.temporal_label().contains("Today's"));
891
892 let summary = session.summary();
894 assert!(!summary.temporal.label.is_empty());
895 assert_eq!(summary.display_title(), session.temporal_label());
896 }
897}