Skip to main content

mps/llm/
session.rs

1use crate::llm::Message;
2use anyhow::{bail, Context as _};
3use chrono::{DateTime, Utc};
4use serde::{Deserialize, Serialize};
5use std::path::Path;
6
7#[derive(Debug, Clone, Serialize, Deserialize)]
8pub struct ChatSession {
9    pub version: u32,
10    pub name: String,
11    pub created_at: DateTime<Utc>,
12    pub updated_at: DateTime<Utc>,
13    pub context_config: SessionContextConfig,
14    /// Informational record of which LLM was active when last saved. Never used to select the LLM.
15    pub llm_hint: String,
16    pub messages: Vec<SessionMessage>,
17}
18
19#[derive(Debug, Clone, Serialize, Deserialize)]
20pub struct SessionMessage {
21    pub role: String,
22    pub content: String,
23    pub ts: DateTime<Utc>,
24}
25
26#[derive(Debug, Clone, Serialize, Deserialize, Default)]
27pub struct SessionContextConfig {
28    pub context_days: u64,
29    pub since: Option<String>,
30}
31
32#[derive(Debug, Clone)]
33pub struct SessionSummary {
34    pub name: String,
35    pub updated_at: DateTime<Utc>,
36    pub message_count: usize,
37}
38
39/// Validate a session name: alphanumeric, hyphens, underscores, dots only.
40/// Rejects empty names, names with path separators, and `..`.
41pub fn validate_name(name: &str) -> anyhow::Result<()> {
42    if name.is_empty() {
43        bail!("session name cannot be empty");
44    }
45    if name == ".." || name == "." {
46        bail!("'{}' is not a valid session name", name);
47    }
48    if name.contains('/') || name.contains('\\') {
49        bail!("session name cannot contain path separators: '{}'", name);
50    }
51    if !name
52        .chars()
53        .all(|c| c.is_alphanumeric() || c == '-' || c == '_' || c == '.')
54    {
55        bail!(
56            "session name '{}' contains invalid characters (use letters, digits, -, _, .)",
57            name
58        );
59    }
60    Ok(())
61}
62
63impl ChatSession {
64    pub fn new(name: &str, context_config: SessionContextConfig) -> Self {
65        let now = Utc::now();
66        Self {
67            version: 1,
68            name: name.to_string(),
69            created_at: now,
70            updated_at: now,
71            context_config,
72            llm_hint: String::new(),
73            messages: Vec::new(),
74        }
75    }
76
77    pub fn load(sessions_dir: &Path, name: &str) -> anyhow::Result<Self> {
78        validate_name(name)?;
79        let path = sessions_dir.join(format!("{}.json", name));
80        let content = std::fs::read_to_string(&path)
81            .with_context(|| format!("cannot read session file {}", path.display()))?;
82        let session: ChatSession = serde_json::from_str(&content)
83            .with_context(|| format!("corrupt session file {}", path.display()))?;
84        Ok(session)
85    }
86
87    /// Atomic save (tmp + rename). Creates sessions_dir if it doesn't exist.
88    pub fn save(&mut self, sessions_dir: &Path) -> anyhow::Result<()> {
89        validate_name(&self.name)?;
90        std::fs::create_dir_all(sessions_dir).context("cannot create sessions directory")?;
91        self.updated_at = Utc::now();
92        let path = sessions_dir.join(format!("{}.json", self.name));
93        let tmp = sessions_dir.join(format!("{}.json.tmp.{}", self.name, std::process::id()));
94        let json = serde_json::to_string_pretty(self).context("serialize session")?;
95        std::fs::write(&tmp, &json).context("write session tmp")?;
96        std::fs::rename(&tmp, &path).context("rename session tmp")?;
97        Ok(())
98    }
99
100    /// List all sessions in sessions_dir, sorted by updated_at descending.
101    pub fn list(sessions_dir: &Path) -> anyhow::Result<Vec<SessionSummary>> {
102        if !sessions_dir.exists() {
103            return Ok(Vec::new());
104        }
105        let mut summaries = Vec::new();
106        for entry in std::fs::read_dir(sessions_dir).context("read sessions dir")? {
107            let entry = entry?;
108            let path = entry.path();
109            if path.extension().and_then(|e| e.to_str()) != Some("json") {
110                continue;
111            }
112            if let Ok(content) = std::fs::read_to_string(&path) {
113                if let Ok(session) = serde_json::from_str::<ChatSession>(&content) {
114                    summaries.push(SessionSummary {
115                        name: session.name.clone(),
116                        updated_at: session.updated_at,
117                        message_count: session.messages.len(),
118                    });
119                }
120            }
121        }
122        summaries.sort_by_key(|s| std::cmp::Reverse(s.updated_at));
123        Ok(summaries)
124    }
125
126    /// Convert stored messages to LLM Message format (strips timestamps).
127    pub fn to_messages(&self) -> Vec<Message> {
128        self.messages
129            .iter()
130            .map(|m| Message {
131                role: m.role.clone(),
132                content: m.content.clone(),
133            })
134            .collect()
135    }
136
137    pub fn push_user(&mut self, content: String) {
138        self.messages.push(SessionMessage {
139            role: "user".into(),
140            content,
141            ts: Utc::now(),
142        });
143    }
144
145    pub fn push_assistant(&mut self, content: String) {
146        self.messages.push(SessionMessage {
147            role: "assistant".into(),
148            content,
149            ts: Utc::now(),
150        });
151    }
152}