Skip to main content

nexus_memory_hooks/
session.rs

1//! Session context for extracted data
2
3use chrono::{DateTime, Utc};
4use serde::{Deserialize, Serialize};
5use std::collections::HashMap;
6
7/// Extracted session context from an agent
8#[derive(Debug, Clone, Serialize, Deserialize)]
9pub struct SessionContext {
10    /// Agent type that created this context
11    pub agent_type: String,
12
13    /// When extraction started
14    pub extraction_started: DateTime<Utc>,
15
16    /// When extraction completed
17    pub extraction_completed: Option<DateTime<Utc>>,
18
19    /// Session ID (if available)
20    pub session_id: Option<String>,
21
22    /// Conversation messages
23    pub conversation: Vec<ConversationMessage>,
24
25    /// Decisions made during session
26    pub decisions: Vec<Decision>,
27
28    /// Files worked on
29    pub files: Vec<FileInfo>,
30
31    /// Tasks completed
32    pub tasks: Vec<TaskInfo>,
33
34    /// Key insights/learnings
35    pub insights: Vec<String>,
36
37    /// Errors encountered
38    pub errors: Vec<ErrorInfo>,
39
40    /// Subagent executions (for pi-mono, oh-my-pi)
41    pub subagent_executions: Vec<SubagentExecution>,
42
43    /// Commands run
44    pub commands_run: Vec<String>,
45
46    /// Custom context data
47    pub custom: HashMap<String, serde_json::Value>,
48
49    /// Source of extraction
50    pub extraction_source: String,
51
52    /// Reliability score (0.0-1.0)
53    pub reliability_score: f32,
54
55    /// Optional re-scorer for active sessions
56    #[serde(skip)]
57    pub rescorer: Option<std::sync::Arc<crate::rescorer::SessionRescorer>>,
58}
59
60impl Default for SessionContext {
61    fn default() -> Self {
62        Self {
63            agent_type: String::new(),
64            extraction_started: Utc::now(),
65            extraction_completed: None,
66            session_id: None,
67            conversation: Vec::new(),
68            decisions: Vec::new(),
69            files: Vec::new(),
70            tasks: Vec::new(),
71            insights: Vec::new(),
72            errors: Vec::new(),
73            subagent_executions: Vec::new(),
74            commands_run: Vec::new(),
75            custom: HashMap::new(),
76            extraction_source: "unknown".to_string(),
77            reliability_score: 1.0,
78            rescorer: None,
79        }
80    }
81}
82
83impl SessionContext {
84    /// Create a new session context for an agent
85    pub fn new(agent_type: impl Into<String>) -> Self {
86        Self {
87            agent_type: agent_type.into(),
88            ..Default::default()
89        }
90    }
91
92    /// Create with extraction source
93    pub fn with_source(mut self, source: impl Into<String>) -> Self {
94        self.extraction_source = source.into();
95        self
96    }
97
98    /// Create with reliability score
99    pub fn with_reliability(mut self, score: f32) -> Self {
100        self.reliability_score = score.clamp(0.0, 1.0);
101        self
102    }
103
104    /// Mark extraction as complete
105    pub fn complete(&mut self) {
106        self.extraction_completed = Some(Utc::now());
107    }
108
109    /// Add a conversation message
110    pub fn add_message(&mut self, role: impl Into<String>, content: impl Into<String>) {
111        let content_str = content.into();
112        self.conversation.push(ConversationMessage {
113            role: role.into(),
114            content: content_str.clone(),
115            timestamp: Utc::now(),
116        });
117
118        // Trigger re-score if rescorer is present
119        if let Some(rescorer) = self.rescorer.as_ref() {
120            let rescorer = rescorer.clone();
121            let agent_type = self.agent_type.clone();
122            if let Ok(handle) = tokio::runtime::Handle::try_current() {
123                handle.spawn(async move {
124                    let config = nexus_core::Config::from_env().unwrap_or_default();
125                    let embeddings = if config.embedding.enabled {
126                        nexus_agent::runtime::create_embedding_service(&config).await
127                    } else {
128                        None
129                    };
130                    if rescorer
131                        .on_turn(&content_str, embeddings.as_deref())
132                        .await
133                        .is_some()
134                    {
135                        let _ = rescorer.rescore(embeddings.as_deref(), &agent_type).await;
136                    }
137                });
138            }
139        }
140    }
141
142    /// Add a decision
143    pub fn add_decision(&mut self, decision: Decision) {
144        self.decisions.push(decision);
145    }
146
147    /// Add a file
148    pub fn add_file(&mut self, file: FileInfo) {
149        self.files.push(file);
150    }
151
152    /// Add a task
153    pub fn add_task(&mut self, task: TaskInfo) {
154        self.tasks.push(task);
155    }
156
157    /// Add an insight
158    pub fn add_insight(&mut self, insight: impl Into<String>) {
159        self.insights.push(insight.into());
160    }
161
162    /// Add an error
163    pub fn add_error(&mut self, error: ErrorInfo) {
164        self.errors.push(error);
165    }
166
167    /// Add a command
168    pub fn add_command(&mut self, command: impl Into<String>) {
169        self.commands_run.push(command.into());
170    }
171
172    /// Add custom data
173    pub fn add_custom(&mut self, key: impl Into<String>, value: serde_json::Value) {
174        self.custom.insert(key.into(), value);
175    }
176
177    /// Check if context is empty
178    pub fn is_empty(&self) -> bool {
179        self.conversation.is_empty()
180            && self.decisions.is_empty()
181            && self.files.is_empty()
182            && self.tasks.is_empty()
183            && self.insights.is_empty()
184            && self.errors.is_empty()
185            && self.commands_run.is_empty()
186    }
187
188    /// Get summary statistics
189    pub fn stats(&self) -> SessionStats {
190        SessionStats {
191            message_count: self.conversation.len(),
192            decision_count: self.decisions.len(),
193            file_count: self.files.len(),
194            task_count: self.tasks.len(),
195            insight_count: self.insights.len(),
196            error_count: self.errors.len(),
197            command_count: self.commands_run.len(),
198        }
199    }
200
201    /// Convert to memory content string
202    pub fn to_memory_content(&self) -> String {
203        let mut parts = Vec::new();
204
205        parts.push(format!("Agent: {}", self.agent_type));
206        parts.push(format!("Source: {}", self.extraction_source));
207        parts.push(format!(
208            "Reliability: {:.0}%",
209            self.reliability_score * 100.0
210        ));
211
212        if !self.conversation.is_empty() {
213            parts.push(format!(
214                "\nConversation: {} messages",
215                self.conversation.len()
216            ));
217        }
218
219        if !self.decisions.is_empty() {
220            parts.push(format!("\nDecisions: {}", self.decisions.len()));
221            for decision in &self.decisions {
222                parts.push(format!("  - {}", decision.summary));
223            }
224        }
225
226        if !self.files.is_empty() {
227            parts.push(format!("\nFiles: {}", self.files.len()));
228            for file in &self.files {
229                parts.push(format!("  - {} ({})", file.path, file.action));
230            }
231        }
232
233        if !self.insights.is_empty() {
234            parts.push(format!("\nInsights: {}", self.insights.len()));
235            for insight in &self.insights {
236                parts.push(format!("  - {}", insight));
237            }
238        }
239
240        if !self.errors.is_empty() {
241            parts.push(format!("\nErrors: {}", self.errors.len()));
242            for error in &self.errors {
243                parts.push(format!("  - {}: {}", error.error_type, error.message));
244            }
245        }
246
247        parts.join("\n")
248    }
249}
250
251/// Conversation message
252#[derive(Debug, Clone, Serialize, Deserialize)]
253pub struct ConversationMessage {
254    pub role: String,
255    pub content: String,
256    pub timestamp: DateTime<Utc>,
257}
258
259/// Decision made during session
260#[derive(Debug, Clone, Serialize, Deserialize)]
261pub struct Decision {
262    pub summary: String,
263    pub rationale: Option<String>,
264    pub alternatives: Vec<String>,
265    pub impact: Option<String>,
266    pub timestamp: DateTime<Utc>,
267}
268
269impl Decision {
270    pub fn new(summary: impl Into<String>) -> Self {
271        Self {
272            summary: summary.into(),
273            rationale: None,
274            alternatives: Vec::new(),
275            impact: None,
276            timestamp: Utc::now(),
277        }
278    }
279}
280
281/// File operation info
282#[derive(Debug, Clone, Serialize, Deserialize)]
283pub struct FileInfo {
284    pub path: String,
285    pub action: FileAction,
286    pub lines_added: Option<usize>,
287    pub lines_removed: Option<usize>,
288    pub timestamp: DateTime<Utc>,
289}
290
291impl FileInfo {
292    pub fn new(path: impl Into<String>, action: FileAction) -> Self {
293        Self {
294            path: path.into(),
295            action,
296            lines_added: None,
297            lines_removed: None,
298            timestamp: Utc::now(),
299        }
300    }
301}
302
303/// File action type
304#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
305#[serde(rename_all = "snake_case")]
306pub enum FileAction {
307    Created,
308    Modified,
309    Deleted,
310    Read,
311}
312
313impl std::fmt::Display for FileAction {
314    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
315        match self {
316            FileAction::Created => write!(f, "created"),
317            FileAction::Modified => write!(f, "modified"),
318            FileAction::Deleted => write!(f, "deleted"),
319            FileAction::Read => write!(f, "read"),
320        }
321    }
322}
323
324/// Task info
325#[derive(Debug, Clone, Serialize, Deserialize)]
326pub struct TaskInfo {
327    pub description: String,
328    pub status: TaskStatus,
329    pub started_at: Option<DateTime<Utc>>,
330    pub completed_at: Option<DateTime<Utc>>,
331    pub subagent: Option<String>,
332}
333
334impl TaskInfo {
335    pub fn new(description: impl Into<String>) -> Self {
336        Self {
337            description: description.into(),
338            status: TaskStatus::Pending,
339            started_at: None,
340            completed_at: None,
341            subagent: None,
342        }
343    }
344}
345
346/// Task status
347#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
348#[serde(rename_all = "snake_case")]
349pub enum TaskStatus {
350    Pending,
351    InProgress,
352    Completed,
353    Failed,
354}
355
356/// Error info
357#[derive(Debug, Clone, Serialize, Deserialize)]
358pub struct ErrorInfo {
359    pub error_type: String,
360    pub message: String,
361    pub stack_trace: Option<String>,
362    pub timestamp: DateTime<Utc>,
363}
364
365impl ErrorInfo {
366    pub fn new(error_type: impl Into<String>, message: impl Into<String>) -> Self {
367        Self {
368            error_type: error_type.into(),
369            message: message.into(),
370            stack_trace: None,
371            timestamp: Utc::now(),
372        }
373    }
374}
375
376/// Subagent execution (for pi-mono, oh-my-pi)
377#[derive(Debug, Clone, Serialize, Deserialize)]
378pub struct SubagentExecution {
379    pub subagent_type: String,
380    pub task: String,
381    pub status: String,
382    pub started_at: DateTime<Utc>,
383    pub completed_at: Option<DateTime<Utc>>,
384    pub result_summary: Option<String>,
385}
386
387/// Session statistics
388#[derive(Debug, Clone, Serialize, Deserialize)]
389pub struct SessionStats {
390    pub message_count: usize,
391    pub decision_count: usize,
392    pub file_count: usize,
393    pub task_count: usize,
394    pub insight_count: usize,
395    pub error_count: usize,
396    pub command_count: usize,
397}
398
399#[cfg(test)]
400mod tests {
401    use super::*;
402
403    #[test]
404    fn test_session_context_new() {
405        let ctx = SessionContext::new("claude-code");
406        assert_eq!(ctx.agent_type, "claude-code");
407        assert!(ctx.is_empty());
408    }
409
410    #[test]
411    fn test_session_context_add_items() {
412        let mut ctx = SessionContext::new("test");
413
414        ctx.add_message("user", "Hello");
415        ctx.add_message("assistant", "Hi there!");
416        ctx.add_decision(Decision::new("Use Rust"));
417        ctx.add_file(FileInfo::new("/src/main.rs", FileAction::Created));
418        ctx.add_insight("Rust is fast");
419        ctx.add_command("cargo build");
420
421        assert!(!ctx.is_empty());
422        assert_eq!(ctx.conversation.len(), 2);
423        assert_eq!(ctx.decisions.len(), 1);
424        assert_eq!(ctx.files.len(), 1);
425        assert_eq!(ctx.insights.len(), 1);
426        assert_eq!(ctx.commands_run.len(), 1);
427    }
428
429    #[test]
430    fn test_session_context_to_memory_content() {
431        let mut ctx = SessionContext::new("claude-code")
432            .with_source("native")
433            .with_reliability(0.95);
434
435        ctx.add_insight("Test insight");
436
437        let content = ctx.to_memory_content();
438        assert!(content.contains("claude-code"));
439        assert!(content.contains("native"));
440        assert!(content.contains("95%"));
441        assert!(content.contains("Test insight"));
442    }
443
444    #[test]
445    fn test_session_stats() {
446        let mut ctx = SessionContext::new("test");
447        ctx.add_message("user", "test");
448        ctx.add_decision(Decision::new("decide"));
449        ctx.add_error(ErrorInfo::new("test", "error"));
450
451        let stats = ctx.stats();
452        assert_eq!(stats.message_count, 1);
453        assert_eq!(stats.decision_count, 1);
454        assert_eq!(stats.error_count, 1);
455    }
456}