codetether_agent/session/
mod.rs1use crate::agent::ToolUse;
6use crate::provider::{Message, Usage};
7use anyhow::Result;
8use chrono::{DateTime, Utc};
9use serde::{Deserialize, Serialize};
10use std::path::PathBuf;
11use tokio::fs;
12use uuid::Uuid;
13
14#[derive(Debug, Clone, Serialize, Deserialize)]
16pub struct Session {
17 pub id: String,
18 pub title: Option<String>,
19 pub created_at: DateTime<Utc>,
20 pub updated_at: DateTime<Utc>,
21 pub messages: Vec<Message>,
22 pub tool_uses: Vec<ToolUse>,
23 pub usage: Usage,
24 pub agent: String,
25 pub metadata: SessionMetadata,
26}
27
28#[derive(Debug, Clone, Default, Serialize, Deserialize)]
29pub struct SessionMetadata {
30 pub directory: Option<PathBuf>,
31 pub model: Option<String>,
32 pub shared: bool,
33 pub share_url: Option<String>,
34}
35
36impl Session {
37 pub async fn new() -> Result<Self> {
39 let id = Uuid::new_v4().to_string();
40 let now = Utc::now();
41
42 Ok(Self {
43 id,
44 title: None,
45 created_at: now,
46 updated_at: now,
47 messages: Vec::new(),
48 tool_uses: Vec::new(),
49 usage: Usage::default(),
50 agent: "build".to_string(),
51 metadata: SessionMetadata {
52 directory: Some(std::env::current_dir()?),
53 ..Default::default()
54 },
55 })
56 }
57
58 pub async fn load(id: &str) -> Result<Self> {
60 let path = Self::session_path(id)?;
61 let content = fs::read_to_string(&path).await?;
62 let session: Session = serde_json::from_str(&content)?;
63 Ok(session)
64 }
65
66 pub async fn last() -> Result<Self> {
68 let sessions_dir = Self::sessions_dir()?;
69
70 if !sessions_dir.exists() {
71 anyhow::bail!("No sessions found");
72 }
73
74 let mut entries: Vec<tokio::fs::DirEntry> = Vec::new();
75 let mut read_dir = fs::read_dir(&sessions_dir).await?;
76 while let Some(entry) = read_dir.next_entry().await? {
77 entries.push(entry);
78 }
79
80 if entries.is_empty() {
81 anyhow::bail!("No sessions found");
82 }
83
84 entries.sort_by_key(|e| {
87 std::cmp::Reverse(
88 std::fs::metadata(e.path())
89 .ok()
90 .and_then(|m| m.modified().ok())
91 .unwrap_or(std::time::SystemTime::UNIX_EPOCH),
92 )
93 });
94
95 if let Some(entry) = entries.first() {
96 let content: String = fs::read_to_string(entry.path()).await?;
97 let session: Session = serde_json::from_str(&content)?;
98 return Ok(session);
99 }
100
101 anyhow::bail!("No sessions found")
102 }
103
104 pub async fn save(&self) -> Result<()> {
106 let path = Self::session_path(&self.id)?;
107
108 if let Some(parent) = path.parent() {
109 fs::create_dir_all(parent).await?;
110 }
111
112 let content = serde_json::to_string_pretty(self)?;
113 fs::write(&path, content).await?;
114
115 Ok(())
116 }
117
118 pub fn add_message(&mut self, message: Message) {
120 self.messages.push(message);
121 self.updated_at = Utc::now();
122 }
123
124 pub async fn prompt(&mut self, message: &str) -> Result<SessionResult> {
126 use crate::provider::{ContentPart, Role, ProviderRegistry, CompletionRequest, parse_model_string};
127
128 let registry = ProviderRegistry::from_vault().await?;
130
131 let providers = registry.list();
132 if providers.is_empty() {
133 anyhow::bail!("No providers available. Configure API keys in HashiCorp Vault.");
134 }
135
136 tracing::info!("Available providers: {:?}", providers);
137
138 let (provider_name, model_id) = if let Some(ref model_str) = self.metadata.model {
140 let (prov, model) = parse_model_string(model_str);
141 (prov.map(|s| s.to_string()), model.to_string())
142 } else {
143 (None, String::new())
144 };
145
146 let selected_provider = provider_name.as_deref()
148 .filter(|p| providers.contains(p))
149 .unwrap_or(providers[0]);
150
151 let provider = registry.get(selected_provider)
152 .ok_or_else(|| anyhow::anyhow!("Provider {} not found", selected_provider))?;
153
154 self.add_message(Message {
156 role: Role::User,
157 content: vec![ContentPart::Text { text: message.to_string() }],
158 });
159
160 if self.title.is_none() {
162 self.generate_title().await?;
163 }
164
165 let messages = self.messages.clone();
167
168 let model = if !model_id.is_empty() {
170 model_id
171 } else {
172 match selected_provider {
174 "moonshotai" => "kimi-k2.5".to_string(),
175 "anthropic" => "claude-sonnet-4-20250514".to_string(),
176 "openai" => "gpt-4o".to_string(),
177 "google" => "gemini-2.5-pro".to_string(),
178 "openrouter" => "stepfun/step-3.5-flash:free".to_string(),
179 _ => "kimi-k2.5".to_string(),
180 }
181 };
182
183 let temperature = if model.starts_with("kimi-k2") {
185 Some(1.0)
186 } else {
187 Some(0.7)
188 };
189
190 tracing::info!("Using model: {} via provider: {}", model, selected_provider);
191
192 let request = CompletionRequest {
194 messages,
195 tools: Vec::new(), model,
197 temperature,
198 top_p: None,
199 max_tokens: Some(4096),
200 stop: Vec::new(),
201 };
202
203 let response = provider.complete(request).await?;
205
206 self.add_message(response.message.clone());
208
209 self.save().await?;
211
212 let text = response.message.content
214 .iter()
215 .filter_map(|p| match p {
216 ContentPart::Text { text } => Some(text.clone()),
217 _ => None,
218 })
219 .collect::<Vec<_>>()
220 .join("\n");
221
222 Ok(SessionResult {
223 text,
224 session_id: self.id.clone(),
225 })
226 }
227
228 pub async fn generate_title(&mut self) -> Result<()> {
231 if self.title.is_some() {
232 return Ok(());
233 }
234
235 let first_message = self.messages.iter().find(|m| m.role == crate::provider::Role::User);
237
238 if let Some(msg) = first_message {
239 let text: String = msg
240 .content
241 .iter()
242 .filter_map(|p| match p {
243 crate::provider::ContentPart::Text { text } => Some(text.clone()),
244 _ => None,
245 })
246 .collect::<Vec<_>>()
247 .join(" ");
248
249 self.title = Some(if text.len() > 50 {
251 format!("{}...", &text[..47])
252 } else {
253 text
254 });
255 }
256
257 Ok(())
258 }
259
260 pub async fn regenerate_title(&mut self) -> Result<()> {
263 let first_message = self.messages.iter().find(|m| m.role == crate::provider::Role::User);
265
266 if let Some(msg) = first_message {
267 let text: String = msg
268 .content
269 .iter()
270 .filter_map(|p| match p {
271 crate::provider::ContentPart::Text { text } => Some(text.clone()),
272 _ => None,
273 })
274 .collect::<Vec<_>>()
275 .join(" ");
276
277 self.title = Some(if text.len() > 50 {
279 format!("{}...", &text[..47])
280 } else {
281 text
282 });
283 }
284
285 Ok(())
286 }
287
288 pub fn set_title(&mut self, title: impl Into<String>) {
290 self.title = Some(title.into());
291 self.updated_at = Utc::now();
292 }
293
294 pub fn clear_title(&mut self) {
296 self.title = None;
297 self.updated_at = Utc::now();
298 }
299
300 pub async fn on_context_change(&mut self, regenerate_title: bool) -> Result<()> {
303 self.updated_at = Utc::now();
304
305 if regenerate_title {
306 self.regenerate_title().await?;
307 }
308
309 Ok(())
310 }
311
312 fn sessions_dir() -> Result<PathBuf> {
314 crate::config::Config::data_dir()
315 .map(|d| d.join("sessions"))
316 .ok_or_else(|| anyhow::anyhow!("Could not determine data directory"))
317 }
318
319 fn session_path(id: &str) -> Result<PathBuf> {
321 Ok(Self::sessions_dir()?.join(format!("{}.json", id)))
322 }
323}
324
325#[derive(Debug, Clone, Serialize, Deserialize)]
327pub struct SessionResult {
328 pub text: String,
329 pub session_id: String,
330}
331
332pub async fn list_sessions() -> Result<Vec<SessionSummary>> {
334 let sessions_dir = crate::config::Config::data_dir()
335 .map(|d| d.join("sessions"))
336 .ok_or_else(|| anyhow::anyhow!("Could not determine data directory"))?;
337
338 if !sessions_dir.exists() {
339 return Ok(Vec::new());
340 }
341
342 let mut summaries = Vec::new();
343 let mut entries = fs::read_dir(&sessions_dir).await?;
344
345 while let Some(entry) = entries.next_entry().await? {
346 let path = entry.path();
347 if path.extension().map(|e| e == "json").unwrap_or(false) {
348 if let Ok(content) = fs::read_to_string(&path).await {
349 if let Ok(session) = serde_json::from_str::<Session>(&content) {
350 summaries.push(SessionSummary {
351 id: session.id,
352 title: session.title,
353 created_at: session.created_at,
354 updated_at: session.updated_at,
355 message_count: session.messages.len(),
356 agent: session.agent,
357 });
358 }
359 }
360 }
361 }
362
363 summaries.sort_by(|a, b| b.updated_at.cmp(&a.updated_at));
364 Ok(summaries)
365}
366
367#[derive(Debug, Clone, Serialize, Deserialize)]
369pub struct SessionSummary {
370 pub id: String,
371 pub title: Option<String>,
372 pub created_at: DateTime<Utc>,
373 pub updated_at: DateTime<Utc>,
374 pub message_count: usize,
375 pub agent: String,
376}
377
378#[allow(dead_code)]
380use futures::StreamExt;
381
382#[allow(dead_code)]
383trait AsyncCollect<T> {
384 async fn collect(self) -> Vec<T>;
385}
386
387#[allow(dead_code)]
388impl<S, T> AsyncCollect<T> for S
389where
390 S: futures::Stream<Item = T> + Unpin,
391{
392 async fn collect(mut self) -> Vec<T> {
393 let mut items = Vec::new();
394 while let Some(item) = self.next().await {
395 items.push(item);
396 }
397 items
398 }
399}