1use super::config::agent_data_dir;
2use super::types::{ChatMessage, MessageRole, SessionEvent, SessionMetrics, SessionOp};
3use crate::constants::MESSAGE_PREVIEW_MAX_LEN;
4use serde::{Deserialize, Serialize};
5use std::fs;
6use std::io::Write;
7use std::path::{Path, PathBuf};
8use std::time::{SystemTime, UNIX_EPOCH};
9
10pub fn sessions_dir() -> PathBuf {
12 let dir = agent_data_dir().join("sessions");
13 let _ = fs::create_dir_all(&dir);
14 dir
15}
16
17pub fn session_file_path(session_id: &str) -> PathBuf {
19 SessionPaths::new(session_id).transcript()
20}
21
22#[derive(Debug)]
26pub struct SessionPaths {
27 dir: PathBuf,
28}
29
30impl SessionPaths {
31 pub fn new(session_id: &str) -> Self {
32 let dir = sessions_dir().join(session_id);
33 Self { dir }
34 }
35
36 pub fn dir(&self) -> &Path {
37 &self.dir
38 }
39
40 pub fn transcript(&self) -> PathBuf {
42 self.dir.join("transcript.jsonl")
43 }
44
45 pub fn meta_file(&self) -> PathBuf {
47 self.dir.join("session.json")
48 }
49
50 pub fn transcripts_dir(&self) -> PathBuf {
52 self.dir.join(".transcripts")
53 }
54
55 pub fn teammates_file(&self) -> PathBuf {
57 self.dir.join("teammates.json")
58 }
59
60 pub fn display(&self) -> PathBuf {
62 self.dir.join("display.jsonl")
63 }
64
65 pub fn teammates_dir(&self) -> PathBuf {
67 self.dir.join("teammates")
68 }
69
70 pub fn teammate_dir(&self, sanitized_name: &str) -> PathBuf {
72 self.teammates_dir().join(sanitized_name)
73 }
74
75 pub fn teammate_transcript(&self, sanitized_name: &str) -> PathBuf {
77 self.teammate_dir(sanitized_name).join("transcript.jsonl")
78 }
79
80 pub fn teammate_todos_file(&self, sanitized_name: &str) -> PathBuf {
82 self.teammate_dir(sanitized_name).join("todos.json")
83 }
84
85 pub fn subagents_file(&self) -> PathBuf {
87 self.dir.join("subagents.json")
88 }
89
90 pub fn subagents_dir(&self) -> PathBuf {
92 self.dir.join("subagents")
93 }
94
95 pub fn subagent_dir(&self, sub_id: &str) -> PathBuf {
97 self.subagents_dir().join(sub_id)
98 }
99
100 pub fn subagent_transcript(&self, sub_id: &str) -> PathBuf {
102 self.subagent_dir(sub_id).join("transcript.jsonl")
103 }
104
105 pub fn subagent_todos_file(&self, sub_id: &str) -> PathBuf {
107 self.subagent_dir(sub_id).join("todos.json")
108 }
109
110 pub fn tasks_file(&self) -> PathBuf {
112 self.dir.join("tasks.json")
113 }
114
115 pub fn todos_file(&self) -> PathBuf {
117 self.dir.join("todos.json")
118 }
119
120 pub fn plan_file(&self) -> PathBuf {
122 self.dir.join("plan.json")
123 }
124
125 pub fn skills_file(&self) -> PathBuf {
127 self.dir.join("skills.json")
128 }
129
130 pub fn hooks_file(&self) -> PathBuf {
132 self.dir.join("hooks.json")
133 }
134
135 pub fn sandbox_file(&self) -> PathBuf {
137 self.dir.join("sandbox.json")
138 }
139
140 pub fn loaded_deferred_file(&self) -> PathBuf {
142 self.dir.join("loaded_deferred.json")
143 }
144
145 pub fn ops_file(&self) -> PathBuf {
147 self.dir.join("ops.jsonl")
148 }
149
150 pub fn metrics_file(&self) -> PathBuf {
152 self.dir.join("metrics.json")
153 }
154
155 pub fn ensure_dir(&self) -> std::io::Result<()> {
156 fs::create_dir_all(&self.dir)
157 }
158
159 #[allow(dead_code)]
161 pub fn id(&self) -> &str {
162 self.dir.file_name().and_then(|s| s.to_str()).unwrap_or("")
163 }
164}
165
166pub fn append_session_event(session_id: &str, event: &SessionEvent) -> bool {
170 let paths = SessionPaths::new(session_id);
171 if paths.ensure_dir().is_err() {
172 return false;
173 }
174 let path = paths.transcript();
175 let ok = match serde_json::to_string(event) {
176 Ok(line) => match fs::OpenOptions::new().create(true).append(true).open(&path) {
177 Ok(mut file) => writeln!(file, "{}", line).is_ok(),
178 Err(_) => false,
179 },
180 Err(_) => false,
181 };
182 if ok {
183 update_session_meta_on_event(session_id, event);
184 }
185 ok
186}
187
188pub fn append_session_op(session_id: &str, op: &SessionOp) -> bool {
190 let paths = SessionPaths::new(session_id);
191 if paths.ensure_dir().is_err() {
192 return false;
193 }
194 let path = paths.ops_file();
195 match serde_json::to_string(op) {
196 Ok(line) => match fs::OpenOptions::new().create(true).append(true).open(&path) {
197 Ok(mut file) => writeln!(file, "{}", line).is_ok(),
198 Err(_) => false,
199 },
200 Err(_) => false,
201 }
202}
203
204#[allow(dead_code)]
206pub fn load_session_ops(session_id: &str) -> Vec<SessionOp> {
207 let path = SessionPaths::new(session_id).ops_file();
208 let content = match fs::read_to_string(&path) {
209 Ok(c) => c,
210 Err(_) => return Vec::new(),
211 };
212 let mut ops = Vec::new();
213 for line in content.lines() {
214 let line = line.trim();
215 if line.is_empty() {
216 continue;
217 }
218 if let Ok(op) = serde_json::from_str::<SessionOp>(line) {
219 ops.push(op);
220 }
221 }
222 ops
223}
224
225fn update_session_meta_on_event(session_id: &str, event: &SessionEvent) {
227 let now = SystemTime::now()
228 .duration_since(UNIX_EPOCH)
229 .unwrap_or_default()
230 .as_secs();
231 let mut meta = load_session_meta_file(session_id).unwrap_or_else(|| SessionMetaFile {
232 id: session_id.to_string(),
233 title: String::new(),
234 message_count: 0,
235 created_at: now,
236 updated_at: now,
237 model: None,
238 auto_approve: false,
239 });
240 meta.updated_at = now;
241 match event {
242 SessionEvent::Msg { message: msg, .. } => {
243 meta.message_count += 1;
244 if meta.title.is_empty() && msg.role == MessageRole::User && !msg.content.is_empty() {
245 meta.title = msg.content.chars().take(MESSAGE_PREVIEW_MAX_LEN).collect();
246 }
247 }
248 SessionEvent::Clear => {
249 meta.message_count = 0;
250 }
251 SessionEvent::Restore { messages } => {
252 meta.message_count = messages.len();
253 if meta.title.is_empty()
254 && let Some(first_user) = messages
255 .iter()
256 .find(|m| m.role == MessageRole::User && !m.content.is_empty())
257 {
258 meta.title = first_user
259 .content
260 .chars()
261 .take(MESSAGE_PREVIEW_MAX_LEN)
262 .collect();
263 }
264 }
265 SessionEvent::Metrics { .. } => {}
266 }
267 let _ = save_session_meta_file(&meta);
268}
269
270pub fn find_latest_session_id() -> Option<String> {
272 let dir = sessions_dir();
273 let mut entries: Vec<(std::time::SystemTime, String)> = Vec::new();
274 let read_dir = match fs::read_dir(&dir) {
275 Ok(rd) => rd,
276 Err(_) => return None,
277 };
278 for entry in read_dir.flatten() {
279 let path = entry.path();
280 if !entry.file_type().is_ok_and(|ft| ft.is_dir()) {
281 continue;
282 }
283 let Some(id) = path.file_name().and_then(|s| s.to_str()) else {
284 continue;
285 };
286 let transcript = path.join("transcript.jsonl");
287 if let Ok(meta) = transcript.metadata()
288 && let Ok(modified) = meta.modified()
289 {
290 entries.push((modified, id.to_string()));
291 }
292 }
293 entries.sort_by_key(|b| std::cmp::Reverse(b.0));
294 entries.into_iter().next().map(|(_, id)| id)
295}
296
297pub fn load_session(session_id: &str) -> Vec<ChatMessage> {
299 let path = SessionPaths::new(session_id).transcript();
300 if !path.exists() {
301 return Vec::new();
302 }
303 let content = match fs::read_to_string(&path) {
304 Ok(c) => c,
305 Err(_) => return Vec::new(),
306 };
307 let mut messages: Vec<ChatMessage> = Vec::new();
308 for line in content.lines() {
309 let line = line.trim();
310 if line.is_empty() {
311 continue;
312 }
313 match serde_json::from_str::<SessionEvent>(line) {
314 Ok(event) => match event {
315 SessionEvent::Msg { message, .. } => messages.push(message),
316 SessionEvent::Clear => messages.clear(),
317 SessionEvent::Restore { messages: restored } => messages = restored,
318 SessionEvent::Metrics { .. } => {}
319 },
320 Err(_) => {
321 }
323 }
324 }
325
326 if let Some(sanitized) = sanitize_loaded_messages(&messages) {
330 let restore_event = SessionEvent::Restore {
331 messages: sanitized.clone(),
332 };
333 append_session_event(session_id, &restore_event);
334 messages = sanitized;
335 }
336
337 messages
338}
339
340pub fn load_display_session(session_id: &str) -> Vec<ChatMessage> {
345 let path = SessionPaths::new(session_id).display();
346 if !path.exists() {
347 return Vec::new();
348 }
349 let content = match fs::read_to_string(&path) {
350 Ok(c) => c,
351 Err(_) => return Vec::new(),
352 };
353 let mut messages: Vec<ChatMessage> = Vec::new();
354 for line in content.lines() {
355 let line = line.trim();
356 if line.is_empty() {
357 continue;
358 }
359 if let Ok(event) = serde_json::from_str::<SessionEvent>(line) {
360 match event {
361 SessionEvent::Msg { message, .. } => messages.push(message),
362 SessionEvent::Clear => messages.clear(),
363 SessionEvent::Restore { messages: restored } => messages = restored,
364 SessionEvent::Metrics { .. } => {}
365 }
366 }
367 }
368 messages
369}
370
371fn sanitize_loaded_messages(messages: &[ChatMessage]) -> Option<Vec<ChatMessage>> {
378 let tool_result_ids: std::collections::HashSet<String> = messages
379 .iter()
380 .filter(|m| m.role == MessageRole::Tool)
381 .filter_map(|m| m.tool_call_id.clone())
382 .filter(|id| !id.is_empty())
383 .collect();
384
385 let assistant_tool_call_ids: std::collections::HashSet<String> = messages
386 .iter()
387 .filter(|m| m.role == MessageRole::Assistant)
388 .flat_map(|m| m.tool_calls.as_deref().unwrap_or(&[]))
389 .map(|tc| tc.id.clone())
390 .filter(|id| !id.is_empty())
391 .collect();
392
393 let mut changed = false;
394 let mut out: Vec<ChatMessage> = Vec::with_capacity(messages.len());
395 for msg in messages {
396 if msg.role == MessageRole::Tool {
397 let id = msg.tool_call_id.as_deref().unwrap_or("");
398 if id.is_empty() || !assistant_tool_call_ids.contains(id) {
399 changed = true;
400 continue;
401 }
402 out.push(msg.clone());
403 } else if msg.role == MessageRole::Assistant {
404 if let Some(ref tcs) = msg.tool_calls {
405 let kept: Vec<_> = tcs
406 .iter()
407 .filter(|tc| !tc.id.is_empty() && tool_result_ids.contains(&tc.id))
408 .cloned()
409 .collect();
410 if kept.len() != tcs.len() {
411 changed = true;
412 let mut new_msg = msg.clone();
413 new_msg.tool_calls = if kept.is_empty() { None } else { Some(kept) };
414 if new_msg.tool_calls.is_none() && new_msg.content.trim().is_empty() {
416 continue;
417 }
418 out.push(new_msg);
419 } else {
420 out.push(msg.clone());
421 }
422 } else {
423 out.push(msg.clone());
424 }
425 } else {
426 out.push(msg.clone());
427 }
428 }
429
430 if changed { Some(out) } else { None }
431}
432
433#[allow(dead_code)]
437pub fn read_transcript_with_timestamps(path: &Path) -> Vec<(ChatMessage, u64)> {
438 let content = match fs::read_to_string(path) {
439 Ok(c) => c,
440 Err(_) => return Vec::new(),
441 };
442 let mut out: Vec<(ChatMessage, u64)> = Vec::new();
443 for line in content.lines() {
444 let line = line.trim();
445 if line.is_empty() {
446 continue;
447 }
448 if let Ok(SessionEvent::Msg {
449 message,
450 timestamp_ms,
451 }) = serde_json::from_str::<SessionEvent>(line)
452 {
453 out.push((message, timestamp_ms));
454 }
455 }
456 out
457}
458
459pub fn append_event_to_path(path: &Path, event: &SessionEvent) -> bool {
461 if let Some(parent) = path.parent() {
462 let _ = fs::create_dir_all(parent);
463 }
464 let line = match serde_json::to_string(event) {
465 Ok(s) => s,
466 Err(_) => return false,
467 };
468 match fs::OpenOptions::new().create(true).append(true).open(path) {
469 Ok(mut file) => writeln!(file, "{}", line).is_ok(),
470 Err(_) => false,
471 }
472}
473
474#[derive(Debug, Clone, Serialize, Deserialize)]
478pub struct SessionMetaFile {
479 pub id: String,
481 #[serde(default)]
483 pub title: String,
484 pub message_count: usize,
486 pub created_at: u64,
488 pub updated_at: u64,
490 #[serde(default)]
492 pub model: Option<String>,
493 #[serde(default)]
495 pub auto_approve: bool,
496}
497
498#[derive(Debug, Clone, Serialize, Deserialize)]
500pub struct SessionMeta {
501 pub id: String,
502 #[serde(default, skip_serializing_if = "Option::is_none")]
504 pub title: Option<String>,
505 pub message_count: usize,
506 pub first_message_preview: Option<String>,
507 pub updated_at: u64,
508}
509
510pub fn load_session_meta_file(session_id: &str) -> Option<SessionMetaFile> {
512 let path = SessionPaths::new(session_id).meta_file();
513 let content = fs::read_to_string(path).ok()?;
514 serde_json::from_str(&content).ok()
515}
516
517pub fn save_session_meta_file(meta: &SessionMetaFile) -> bool {
519 let paths = SessionPaths::new(&meta.id);
520 if paths.ensure_dir().is_err() {
521 return false;
522 }
523 match serde_json::to_string_pretty(meta) {
524 Ok(json) => fs::write(paths.meta_file(), json).is_ok(),
525 Err(_) => false,
526 }
527}
528
529fn derive_session_meta_from_transcript(session_id: &str) -> Option<SessionMetaFile> {
531 let paths = SessionPaths::new(session_id);
532 let transcript = paths.transcript();
533 let content = fs::read_to_string(&transcript).ok()?;
534
535 let mut message_count: usize = 0;
536 let mut first_user_preview: Option<String> = None;
537 for line in content.lines() {
538 let line = line.trim();
539 if line.is_empty() {
540 continue;
541 }
542 if let Ok(event) = serde_json::from_str::<SessionEvent>(line) {
543 match event {
544 SessionEvent::Msg {
545 message: ref msg, ..
546 } => {
547 message_count += 1;
548 if first_user_preview.is_none()
549 && msg.role == MessageRole::User
550 && !msg.content.is_empty()
551 {
552 first_user_preview =
553 Some(msg.content.chars().take(MESSAGE_PREVIEW_MAX_LEN).collect());
554 }
555 }
556 SessionEvent::Clear => {
557 message_count = 0;
558 first_user_preview = None;
559 }
560 SessionEvent::Restore { ref messages } => {
561 message_count = messages.len();
562 first_user_preview = messages
563 .iter()
564 .find(|m| m.role == MessageRole::User && !m.content.is_empty())
565 .map(|m| m.content.chars().take(MESSAGE_PREVIEW_MAX_LEN).collect());
566 }
567 SessionEvent::Metrics { .. } => {}
568 }
569 }
570 }
571
572 let updated_at = transcript
573 .metadata()
574 .ok()
575 .and_then(|m| m.modified().ok())
576 .and_then(|t| t.duration_since(UNIX_EPOCH).ok())
577 .map(|d| d.as_secs())
578 .unwrap_or(0);
579
580 Some(SessionMetaFile {
581 id: session_id.to_string(),
582 title: first_user_preview.clone().unwrap_or_default(),
583 message_count,
584 created_at: updated_at,
585 updated_at,
586 model: None,
587 auto_approve: false,
588 })
589}
590
591pub fn list_sessions() -> Vec<SessionMeta> {
596 let dir = sessions_dir();
597 let read_dir = match fs::read_dir(&dir) {
598 Ok(rd) => rd,
599 Err(_) => return Vec::new(),
600 };
601
602 let mut ids: Vec<String> = Vec::new();
603 for entry in read_dir.flatten() {
604 let path = entry.path();
605 if !entry.file_type().is_ok_and(|ft| ft.is_dir()) {
606 continue;
607 }
608 let Some(id) = path.file_name().and_then(|s| s.to_str()) else {
609 continue;
610 };
611 if path.join("transcript.jsonl").exists() {
612 ids.push(id.to_string());
613 }
614 }
615
616 let mut sessions: Vec<SessionMeta> = Vec::with_capacity(ids.len());
617 for id in ids {
618 if let Some(meta_file) = load_session_meta_file(&id) {
620 sessions.push(SessionMeta {
621 id: meta_file.id,
622 title: if meta_file.title.is_empty() {
623 None
624 } else {
625 Some(meta_file.title)
626 },
627 message_count: meta_file.message_count,
628 first_message_preview: None,
629 updated_at: meta_file.updated_at,
630 });
631 continue;
632 }
633
634 if let Some(derived) = derive_session_meta_from_transcript(&id) {
636 let title = if derived.title.is_empty() {
637 None
638 } else {
639 Some(derived.title.clone())
640 };
641 let preview_for_ui = title.clone();
642 let _ = save_session_meta_file(&derived);
643 sessions.push(SessionMeta {
644 id: derived.id,
645 title,
646 message_count: derived.message_count,
647 first_message_preview: preview_for_ui,
648 updated_at: derived.updated_at,
649 });
650 }
651 }
652 sessions.sort_by_key(|b| std::cmp::Reverse(b.updated_at));
653 sessions
654}
655
656pub fn generate_session_id() -> String {
658 let ts = SystemTime::now()
659 .duration_since(UNIX_EPOCH)
660 .unwrap_or_default()
661 .as_micros();
662 let pid = std::process::id();
663 format!("{:x}-{:x}", ts, pid)
664}
665
666pub fn delete_session(session_id: &str) -> bool {
668 let paths = SessionPaths::new(session_id);
669 let dir = paths.dir().to_path_buf();
670 if dir.exists()
671 && let Err(e) = fs::remove_dir_all(&dir)
672 {
673 eprintln!("[ERROR] ✖️ 删除 session 目录失败: {}", e);
674 return false;
675 }
676 true
677}
678
679pub fn write_session_metrics(session_id: &str, metrics: &SessionMetrics) -> bool {
681 let paths = SessionPaths::new(session_id);
682 let path = paths.metrics_file();
683 match serde_json::to_string_pretty(metrics) {
684 Ok(json) => fs::write(&path, json).is_ok(),
685 Err(_) => false,
686 }
687}